﻿// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using Cake.Core;
using Cake.Core.IO;

namespace Cake.Testing
{
    internal sealed class FakeFileSystemTree
    {
        private readonly FakeDirectory _root;
        private readonly Func<TimeProvider> _timeprovider;

        public bool IsUnix { get; }
        public PathComparer Comparer { get; }
        public UnixFileMode? DefaultUnixCreateDirectoryMode { get; }

        public UnixFileMode? DefaultUnixCreateFileMode { get; }
        public TimeProvider TimeProvider => _timeprovider();
        public DateTime GetUtcNow() => TimeProvider.GetUtcNow().DateTime;

        public FakeFileSystemTree(ICakeEnvironment environment, Func<TimeProvider> timeprovider)
        {
            ArgumentNullException.ThrowIfNull(environment);
            ArgumentNullException.ThrowIfNull(timeprovider);

            if (environment.WorkingDirectory == null)
            {
                throw new ArgumentException("Working directory not set.");
            }

            if (environment.WorkingDirectory.IsRelative)
            {
                throw new ArgumentException("Working directory cannot be relative.");
            }

            IsUnix = environment.Platform.IsUnix();

            Comparer = new PathComparer(IsUnix);
            if (IsUnix)
            {
                DefaultUnixCreateDirectoryMode = UnixFileMode.UserRead
                                                    | UnixFileMode.UserWrite
                                                    | UnixFileMode.UserExecute
                                                    | UnixFileMode.GroupRead
                                                    | UnixFileMode.GroupWrite
                                                    | UnixFileMode.GroupExecute
                                                    | UnixFileMode.OtherRead
                                                    | UnixFileMode.OtherWrite
                                                    | UnixFileMode.OtherExecute;
                DefaultUnixCreateFileMode = UnixFileMode.UserRead
                                                | UnixFileMode.UserWrite
                                                | UnixFileMode.GroupRead
                                                | UnixFileMode.GroupWrite
                                                | UnixFileMode.OtherRead
                                                | UnixFileMode.OtherWrite;
            }

            _timeprovider = timeprovider;

            var now = GetUtcNow();
            _root = new FakeDirectory(this, "/")
            {
                Exists = true,
            };
            _root.SetLastWriteTimeUtc(now);
            _root.SetCreationTimeUtc(now);
            _root.SetLastAccessTimeUtc(now);
            _root.Create();
        }

        [StackTraceHidden]
        public static FileNotFoundException ThrowIfNotFound(IFile file)
        {
            const string IOFileNotFound = "Could not find the specified file.";
            const string IOFileNotFoundFileName = "Could not find file '{0}'.";

            var exception = file?.Path == null
                ? new FileNotFoundException(IOFileNotFound)
                : new FileNotFoundException(string.Format(IOFileNotFoundFileName, file.Path.FullPath), file.Path.FullPath);

            if (file?.Exists != true)
            {
                throw exception;
            }

            return exception;
        }

        [StackTraceHidden]
        public static DirectoryNotFoundException ThrowIfNotFound(IDirectory directory)
        {
            const string IODirectoryNotFound = "Could not find the specified directory.";
            const string IODirectoryNotFoundFileName = "Could not find directory '{0}'.";

            var exception = new DirectoryNotFoundException(
                directory?.Path == null
                    ? IODirectoryNotFound
                    : string.Format(IODirectoryNotFoundFileName, directory.Path.FullPath));

            if (directory?.Exists != true)
            {
                throw exception;
            }

            return exception;
        }

        public FakeDirectory CreateDirectory(DirectoryPath path)
        {
            return CreateDirectory(new FakeDirectory(this, path));
        }

        public FakeDirectory CreateDirectory(FakeDirectory directory)
        {
            var now = GetUtcNow();
            var path = directory.Path;
            var queue = new Queue<string>(path.Segments);

            FakeDirectory current = null;
            var children = _root.Content;

            while (queue.Count > 0)
            {
                // Get the segment.
                var currentSegment = queue.Dequeue();
                var parent = current;

                // Calculate the current path.
                path = parent != null ? parent.Path.Combine(currentSegment) : new DirectoryPath(currentSegment);

                if (!children.Directories.TryGetValue(path, out var childDirectory))
                {
                    current = queue.Count == 0 ? directory : new FakeDirectory(this, path);
                    current.Parent = parent ?? _root;
                    current.Hidden = false;
                    children.Add(current);
                }
                else
                {
                    current = childDirectory;
                }

                current.Exists = true;
                current.SetLastWriteTimeUtc(now);
                current.SetCreationTimeUtc(now);
                current.SetLastAccessTimeUtc(now);
                current.UnixFileMode = DefaultUnixCreateDirectoryMode;
                children = current.Content;
            }

            return directory;
        }

        public void CreateFile(FakeFile file)
        {
            // Get the directory that the file exists in.
            var directory = FindDirectory(file.Path.GetDirectory());
            if (directory == null)
            {
                file.Exists = false;
                ThrowIfNotFound(directory);
            }

            if (!directory.Content.Files.ContainsKey(file.Path))
            {
                // Add the file to the directory.
                file.Exists = true;
                var now = GetUtcNow();
                file.SetLastWriteTimeUtc(now);
                file.SetCreationTimeUtc(now);
                file.SetLastAccessTimeUtc(now);
                directory.Content.Add(file);
            }
        }

        public void DeleteDirectory(FakeDirectory fakeDirectory, bool recursive)
        {
            var root = new Stack<FakeDirectory>();
            var result = new Stack<FakeDirectory>();

            if (fakeDirectory.Exists)
            {
                root.Push(fakeDirectory);
            }

            while (root.Count > 0)
            {
                var node = root.Pop();
                result.Push(node);

                var directories = node.Content.Directories;

                if (directories.Count > 0 && !recursive)
                {
                    throw new IOException("The directory is not empty.");
                }

                foreach (var child in directories)
                {
                    root.Push(child.Value);
                }
            }

            while (result.Count > 0)
            {
                var directory = result.Pop();

                var files = directory.Content.Files.Select(x => x).ToArray();
                if (files.Length > 0 && !recursive)
                {
                    throw new IOException("The directory is not empty.");
                }

                foreach (var file in files)
                {
                    // Delete the file.
                    DeleteFile(file.Value);
                }

                // Delete the directory.
                directory.Parent.Content.Remove(directory);
                directory.Exists = false;
            }
        }

        public void DeleteFile(FakeFile file)
        {
            ThrowIfNotFound(file);

            if (file.Attributes.HasFlag(FileAttributes.ReadOnly))
            {
                throw new IOException($"Cannot delete readonly file '{file.Path.FullPath}'.");
            }

            // Find the directory.
            var directory = FindDirectory(file.Path.GetDirectory());

            // Remove the file from the directory.
            directory.Content.Remove(file);

            // Reset all properties.
            file.Reset();
        }

        public FakeDirectory FindDirectory(DirectoryPath path)
        {
            // Want the root?
            if (path.Segments.Length == 0)
            {
                return _root;
            }

            var queue = new Queue<string>(path.Segments);

            FakeDirectory current = null;
            var children = _root.Content;

            while (queue.Count > 0)
            {
                // Set the parent.
                var parent = current;

                // Calculate the current path.
                var segment = queue.Dequeue();
                path = parent != null ? parent.Path.Combine(segment) : new DirectoryPath(segment);

                // Find the current path.
                if (!children.Directories.TryGetValue(path, out var directory))
                {
                    return null;
                }

                current = directory;
                children = current.Content;
            }

            return current;
        }

        public FakeFile FindFile(FilePath path)
        {
            var directory = FindDirectory(path.GetDirectory());
            if (directory != null)
            {
                if (directory.Content.Files.TryGetValue(path, out var file))
                {
                    return file;
                }
            }
            return null;
        }

        public void CopyFile(FakeFile file, FilePath destination, bool overwrite)
        {
            ThrowIfNotFound(file);

            // Already exists?
            var destinationFile = FindFile(destination);
            if (destinationFile != null)
            {
                if (!overwrite)
                {
                    const string format = "{0} exists and overwrite is false.";
                    var message = string.Format(CultureInfo.InvariantCulture, format, destination.FullPath);
                    throw new IOException(message);
                }
            }

            // Directory exists?
            var directory = FindDirectory(destination.GetDirectory());
            if (directory == null || !directory.Exists)
            {
                ThrowIfNotFound(directory);
            }

            // Make sure the file exist.
            destinationFile ??= new FakeFile(this, destination);

            // Copy the data from the original file to the destination.
            using var input = file.OpenRead();
            using var output = destinationFile.OpenWrite();
            input.CopyTo(output);
            destinationFile.Attributes = file.Attributes;
            destinationFile.SetCreationTimeUtc(file.CreationTimeUtc ?? GetUtcNow());
            if (file.UnixFileMode.HasValue)
            {
                destinationFile.SetUnixFileMode(file.UnixFileMode.Value);
            }
        }

        public void MoveFile(FakeFile fakeFile, FilePath destination)
        {
            // Copy the file to the new location.
            CopyFile(fakeFile, destination, false);

            // Delete the original file.
            fakeFile.Delete();
        }

        public void MoveDirectory(FakeDirectory fakeDirectory, DirectoryPath destination)
        {
            var root = new Stack<FakeDirectory>();
            var result = new Stack<FakeDirectory>();

            if (string.IsNullOrEmpty(destination.FullPath))
            {
                throw new ArgumentException("The destination directory is empty.");
            }

            if (fakeDirectory.Path.Equals(destination))
            {
                throw new IOException("The directory being moved and the destination directory have the same name.");
            }

            if (FindDirectory(destination) != null)
            {
                throw new IOException("The destination directory already exists.");
            }

            string destinationParentPathStr = string.Join('/', destination.Segments.Take(destination.Segments.Length - 1).DefaultIfEmpty("/"));
            DirectoryPath destinationParentPath = new DirectoryPath(destinationParentPathStr == string.Empty ? "/" : destinationParentPathStr);
            if (FindDirectory(destinationParentPath) == null)
            {
                throw new DirectoryNotFoundException("The parent destination directory " + destinationParentPath.FullPath + " could not be found.");
            }

            if (fakeDirectory.Exists)
            {
                root.Push(fakeDirectory);
            }

            // Create destination directories and move files
            while (root.Count > 0)
            {
                var node = root.Pop();
                result.Push(node);

                // Create destination directory
                DirectoryPath relativePath = fakeDirectory.Path.GetRelativePath(node.Path);
                DirectoryPath destinationPath = destination.Combine(relativePath);
                CreateDirectory(destinationPath);

                var files = node.Content.Files.Select(x => x).ToArray();
                foreach (var file in files)
                {
                    // Move the file.
                    MoveFile(file.Value, destinationPath.CombineWithFilePath(file.Key.GetFilename()));
                }

                var directories = node.Content.Directories;
                foreach (var child in directories)
                {
                    root.Push(child.Value);
                }
            }

            // Delete source directories
            while (result.Count > 0)
            {
                var directory = result.Pop();

                // Delete the directory.
                directory.Parent.Content.Remove(directory);
                directory.Exists = false;
            }
        }
    }
}